#!/usr/bin/env python
# -*- coding: utf-8 -*-
# created at 27.03.2011

# This software is distributed under the BSD license.
# http://www.opensource.org/licenses/bsd-license.php 
# Copyright © 2011, Oleg Churkin

"""
    **Easylogger** is a wrapper for standard :mod:`logging` module, which provides some new
    logging facilities and allows accessing them in more Pythonic way.

    :copyright: Copyright 2011 by Oleg Churkin <bahusoff@gmail.com>.
    :license: BSD, see LICENSE.txt for details.
"""

import sys
import inspect

# Python version check: 2.6 and higher is required.
if (sys.version_info[0] < 2 or 
    (sys.version_info[0] == 2 and sys.version_info[1] < 6)):
    raise ImportError("Minimum Python version 2.6 is required."
                      " Your current Python version is"
                      " %s.%s." % sys.version_info[:2])

import logging
import functools
from logging import (INFO, DEBUG, CRITICAL, WARN,   #IGNORE:W0611 @UnusedImport
                     WARNING, ERROR, FATAL, NOTSET) #@UnusedImport


__all__ = ["INFO", "DEBUG", "CRITICAL", "WARN", "WARNING", "ERROR", "FATAL",
           "NOTSET", "DOC", "STDOUT", "STDERR", "log", "info", "debug", "error",
           "warning", "warn","exception", "critical", "compose_handler",
           "get_logger", "get_default_logger", "LoggingOptions", "LoggerAdapter"]

# logging level priorities:
# NOTSET < DEBUG < DOC < INFO < STDOUT < WARNING (WARN) < ERROR < STDERR < CRITICAL (FATAL)

DOC = 15
logging.addLevelName(DOC, "DOC")
STDOUT = 25
logging.addLevelName(STDOUT, "STDOUT")
STDERR = 45
logging.addLevelName(STDERR, "STDERR")

# to detect Python 3K
PY3 = sys.version_info >= (3,)


class Defaults(object): #IGNORE:R0903
    """Namespace with default values."""
    
    GLOBAL_LOGGING_LEVEL = DEBUG
    STREAM_MESSAGE_FORMAT = ("%(asctime)s [%(levelname)-8s] %(message)s", "%H:%M:%S")
    FILE_MESSAGE_FORMAT = "%(asctime)s [%(levelname)-8s] %(message)s"


class LoggingOptions(object): #IGNORE:R0903
    """This structure contains different logging options.
    
    Available options:
        * **indent_size** - specifies number of spaces to be inserted before
            '%(message)s' parameter, see logging.Formatter for more details;
        * **enable_new_style** - if ``True`` log's methods will join all provided
            positional arguments with space as delimiter, e.g.:
                >>> log.info("I", "am", "fine.")
                I am fine.
        * **new_style_delimiter** - delimiter between message parts when new
            message style is enabled, space by default;
        * **enable_decorator_mode** - if ``True`` log's methods will produce necessary
            messages when used as decorators, otherwise no output will be produced;
        * **enable_doc_processing** - if ``True`` all doc strings will be logged by log
            or its method used as decorators;
        * **doc_processing_prefix** - if specified, only doc strings begins with
            this character(s) will be logged. Character(s) will be stripped
            from logged message;
        * **doc_processing_level** - logging level for logged doc strings, by default
            it's :const:`DOC`;
    """
    indent = None
    enable_new_style = True
    new_style_delimiter = " "
    enable_decorator_mode = True
    enable_doc_processing = True
    doc_processing_prefix = None
    doc_processing_level = DOC


def compose_handler(handler, level=None, format_tuple=None, filter_name=None):
    """Help to compose final handler.
    
    :param handler: any handler object from logging and logging.handlers;
    :param level: logging level (``INFO``, ``DEBUG`` and so on);
    :param format_tuple: message format tuple (fmt, datefmt), see :class:`logging.Formatter`.
        Only if string is provided, ``datefmt`` will be passed as None;
    :param filter_name: filter name, see :class:`logging.Filter`;
    """
    
    if level is not None:
        handler.setLevel(level)
    
    if format_tuple is not None:
        if isinstance(format_tuple, basestring):
            format_tuple = (format_tuple, None)
        handler.setFormatter(logging.Formatter(fmt=format_tuple[0],
                                               datefmt=format_tuple[1]))
    
    if filter_name is not None:
        handler.addFilter(logging.Filter(name=filter_name))
    
    return handler
    

def get_logger(name=None, level=None, handlers=None, options=None, extra=None):
    """Return basic logger object.
        
    :param name: logger's name;
    :param level: logging level, if not specified and some handlers are provided,
        minimum of all their levels will be taken. If final value is ``NOTSET``,
        then :const:`Defaults.GLOBAL_LOGGING_LEVEL` will be used;
    :param handlers: list of handlers for created logger to attach,
        if value is not iterable, it will be put in
        a list automatically;
    :param options: :class:`LoggingOptions` object;
    :param extra: extra arguments for underlying logging functionality, see 
        documentation for :mod:`logging`;
    """
    if level is None:
        level = min([_.level for _ in (handlers or [])] or [NOTSET])
        if level == NOTSET:
            level = Defaults.GLOBAL_LOGGING_LEVEL
        
    if name:
        logger = logging.getLogger(name)
        logger.setLevel(level)
    else:
        logger = logging.RootLogger(level)
    
    if handlers is not None:
        if not isinstance(handlers, (list, tuple)):
            handlers = [handlers]
            
        for handler in handlers:
            logger.addHandler(handler)
        
    return LoggerAdapter(logger, options, extra)


def get_default_logger(stream=None):
    """Return default logger for this module.
    
    :param stream: stream object for default logging handler :class:`logging.StreamHandler`,
        default value is ``sys.__stdout__``.
    """
    if stream is None:
        stream = sys.__stdout__
    stream_handler = compose_handler(logging.StreamHandler(stream),
                                     Defaults.GLOBAL_LOGGING_LEVEL,
                                     Defaults.STREAM_MESSAGE_FORMAT)
    return get_logger(handlers=[stream_handler])    


# this function copied from inspect module in Python 2.7, the algorithm was
# slightly changed: now it returns list of tuples: 
# [(name, value), ...] to retain sequence of attributes.
# Ironically ordered dictionary can't be used here, since it was added in 2.7 only.
def getcallargs(func, *positional, **named): #IGNORE:R0912 #IGNORE:R0914
    """Get the mapping of arguments to values.

    A dict is returned, with keys the function argument names (including the
    names of the * and ** arguments, if any), and values the respective bound
    values from 'positional' and 'named'."""
    args, varargs, varkw, defaults = inspect.getargspec(func)
    f_name = func.__name__
    
    arg2value_keys = []
    arg2value_vals = []

    # The following closures are basically because of tuple parameter unpacking.
    assigned_tuple_params = []
    def assign(arg, value): #IGNORE:C0111
        if isinstance(arg, str):
            arg2value_keys.append(arg)
            arg2value_vals.append(value)
        else:
            assigned_tuple_params.append(arg)
            value = iter(value)
            for i, subarg in enumerate(arg):
                try:
                    subvalue = next(value)
                except StopIteration:
                    raise ValueError('need more than %d %s to unpack' %
                                     (i, 'values' if i > 1 else 'value'))
                assign(subarg, subvalue)
            try:
                next(value)
            except StopIteration:
                pass
            else:
                raise ValueError('too many values to unpack')
    def is_assigned(arg): #IGNORE:C0111
        if isinstance(arg, str):
            return arg in arg2value_keys
        return arg in assigned_tuple_params
    if inspect.ismethod(func) and func.im_self is not None:
        # implicit 'self' (or 'cls' for classmethods) argument
        positional = (func.im_self,) + positional
    num_pos = len(positional)
    num_total = num_pos + len(named)
    num_args = len(args)
    num_defaults = len(defaults) if defaults else 0
    for arg, value in zip(args, positional):
        assign(arg, value)
    if varargs:
        if num_pos > num_args:
            assign(varargs, positional[-(num_pos-num_args):])
        else:
            assign(varargs, ())
    elif 0 < num_args < num_pos:
        raise TypeError('%s() takes %s %d %s (%d given)' % (
            f_name, 'at most' if defaults else 'exactly', num_args,
            'arguments' if num_args > 1 else 'argument', num_total))
    elif num_args == 0 and num_total:
        raise TypeError('%s() takes no arguments (%d given)' %
                        (f_name, num_total))
    for arg in args:
        if isinstance(arg, str) and arg in named:
            if is_assigned(arg):
                raise TypeError("%s() got multiple values for keyword "
                                "argument '%s'" % (f_name, arg))
            else:
                assign(arg, named.pop(arg))
    if defaults:    # fill in any missing values with the defaults
        for arg, value in zip(args[-num_defaults:], defaults):
            if not is_assigned(arg):
                assign(arg, value)
    if varkw:
        assign(varkw, named)
    elif named:
        unexpected = next(iter(named))
        if isinstance(unexpected, unicode):
            unexpected = unexpected.encode(sys.getdefaultencoding(), 'replace')
        raise TypeError("%s() got an unexpected keyword argument '%s'" %
                        (f_name, unexpected))
    unassigned = num_args - len([arg for arg in args if is_assigned(arg)])
    if unassigned:
        num_required = num_args - num_defaults
        raise TypeError('%s() takes %s %d %s (%d given)' % (
            f_name, 'at least' if defaults else 'exactly', num_required,
            'arguments' if num_required > 1 else 'argument', num_total))
    return zip(arg2value_keys, arg2value_vals)


if PY3:
    def _smart_encode(string, encoding="UTF-8", errors="replace"):
        return string
else:
    def _smart_encode(string, encoding="UTF-8", errors="replace"):
        """Encode Unicode-based string to Python byte-string to avoid
           some UnicodeEncodeErrors. Not applicable in Python 3."""
        if isinstance(string, unicode):
           
            try:
                return string.encode(encoding, errors)
            except UnicodeError:
                pass
               
        return string


class _LoggerDecoratorHelper(object): #IGNORE:R0903
    """Logger helper."""
    
    def __init__(self, logger, level):
        self.logger = logger
        self.level = level
        self.options = self.logger.options
        self._cache = None
    
    def _get_arguments(self, func, args, kwargs):
        """Return arguments and their values provided for function 'func'.
        This information should be cached for current session. Probably
        cache should be used for every equal triple (func, args, kwargs).
        """
        if self._cache is None:
            self._cache = getcallargs(func, *args, **kwargs)
        return self._cache
    
    def _parameters_to_string(self, func, args, kwargs): #IGNORE:R0201
        """Format provided arguments to output them as Python code."""
        
        items = self._get_arguments(func, args, kwargs)
        parent = dict(items).pop("self", None)
        
        _ = []
        for k, v in items:
            if k != "self":
                _.append("{0}={1}".format(k, "'{0}'".format(_smart_encode(v)) if isinstance(v, basestring) else v))        
        
        if parent:
            # it's a method
            
            # support for classmethods
            if not inspect.isclass(parent):
                parent = parent.__class__
                
            func_info = "method {0}.{1}".format(parent.__name__,
                                                func.__name__)
        else:
            # it's a function
            func_info = "function {0}".format(func.__name__)
        
        return "Executing {0}({1}).".format(func_info, ", ".join(_))

    def _doc_processing(self, func, args, kwargs):
        """Log doc stings."""
        doc = inspect.getdoc(func)
        if doc is not None:
            # formatting
            try:
                doc = doc.format(**dict(self._get_arguments(func, args, kwargs)))
            except KeyError:
                pass
            
            for _ in doc.split("\n"):
                if self.options.doc_processing_prefix:
                    if _.startswith(self.options.doc_processing_prefix):
                        _ = _[len(self.options.doc_processing_prefix):]
                    else:
                        continue
                    
                self.logger.log(self.options.doc_processing_level, _)        
        
    def __call__(self, func):
        
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            """ Simple wrapper."""
            
            if self.options.enable_decorator_mode:
                self.logger.log(self.level,
                                self._parameters_to_string(func, args, kwargs))
                
                # log doc-strings
                if self.options.enable_doc_processing:
                    self._doc_processing(func, args, kwargs)
            
                # empty cached values:
                self._cache = None
            
            # execute the function
            return func(*args, **kwargs)
        
        return wrapper


class _LoggerStreamHelper(object):
    """Imitation of stream object. 
    
    sys.stdout (sys.stderr) can be replaced with this imitation.
    
    Supported methods: 
        write, writelines, close, and flush
    """
    
    def __init__(self, logger, level):
        self.logger = logger
        self.level = level
        self.closed = False
        self._cache = []
    
    def write(self, msg):
        """Write provided string to a stream."""
        if not self.closed:
            
            if not msg.endswith("\n"):
                self._cache.append(msg)
            else:
                self._cache.append(msg.strip("\n"))
                self.flush()

    def writelines(self, lines):
        """Write a sequence of strings to the file."""
        for line in lines:
            self.write(line)
    
    def flush(self):
        """Flush cached messages to logger."""
        if self._cache:
            self.logger.log(self.level, "".join(self._cache))
            self._cache = []
    
    def close(self):
        """Close current stream."""
        self.closed = True
    

# Copied from logging module and updated.
class LoggerAdapter(object):
    """
    An adapter for loggers which makes it easier to specify contextual
    information in logging output.
    """

    def __init__(self, logger=None, options=None, extra=None):
        """
        Initialize the adapter with a logger and a dict-like object which
        provides contextual information. This constructor signature allows
        easy stacking of LoggerAdapters, if so desired.

        You can effectively pass keyword arguments as shown in the
        following example:

        adapter = LoggerAdapter(someLogger, extra=dict(p1=v1, p2="v2"))
        """
        self.logger = logger
        self.extra = extra or {}
        self.options = options or LoggingOptions()

    def process_message(self, msg, args, kwargs):
        """Process the logging message and keyword arguments passed in to
        a logging call to insert contextual information. You can either
        manipulate the message itself, args or keyword args. Return
        the message, args and kwargs modified (or not) to suit your needs.

        """
        
        self.extra.update(kwargs.get("extra", {}))
        kwargs["extra"] = self.extra
        
        # processing with 'indent' options
        if self.options.indent is not None:
            if isinstance(self.options.indent, int):
                indent = " " * self.options.indent
            else:
                indent = self.options.indent
            
            msg = "{0}{1}".format(indent, _smart_encode(msg))
        
        # processing with new message system
        if self.options.enable_new_style:
            # TODO: support new formatting style
            args = (msg, ) + args
            msg = self.options.new_style_delimiter.join(["%s" for _ in args])
        
        return msg, args, kwargs

    def debug(self, msg, *args, **kwargs):
        """
        Delegate a debug call to the underlying logger, after adding
        contextual information from this adapter instance.
        """
        return self.log(DEBUG, msg, *args, **kwargs)

    def info(self, msg, *args, **kwargs):
        """
        Delegate an info call to the underlying logger, after adding
        contextual information from this adapter instance.
        """
        return self.log(INFO, msg, *args, **kwargs)

    def warning(self, msg, *args, **kwargs):
        """
        Delegate a warning call to the underlying logger, after adding
        contextual information from this adapter instance.
        """
        return self.log(WARNING, msg, *args, **kwargs)

    def error(self, msg, *args, **kwargs):
        """
        Delegate an error call to the underlying logger, after adding
        contextual information from this adapter instance.
        """
        return self.log(ERROR, msg, *args, **kwargs)

    def exception(self, msg, *args, **kwargs):
        """
        Delegate an exception call to the underlying logger, after adding
        contextual information from this adapter instance.
        """
        kwargs["exc_info"] = 1
        return self.log(ERROR, msg, *args, **kwargs)

    def critical(self, msg, *args, **kwargs):
        """
        Delegate a critical call to the underlying logger, after adding
        contextual information from this adapter instance.
        """
        return self.log(CRITICAL, msg, *args, **kwargs)

    def log(self, level, msg, *args, **kwargs):
        """
        Delegate a log call to the underlying logger, after adding
        contextual information from this adapter instance.
        """
        # we need to detect if log's method used as decorators, if so msg 
        # should be a function (or method) and args, kwargs must be empty.
        if ((inspect.ismethod(msg) or inspect.isfunction(msg)) and 
             not args and 
             (not kwargs or ("exc_info" in kwargs and len(kwargs.keys())==1))):
            # ok, we are in 'decorator' mode
            return _LoggerDecoratorHelper(self, level)(msg)
                
        msg, args, kwargs = self.process_message(msg, args, kwargs)
        self.logger.log(level, msg, *args, **kwargs)

    def append_handlers(self, *handlers):
        """Append provided handlers to the list of existed ones."""
        for handler in handlers:
            self.logger.addHandler(handler)
    
    def replace_handlers(self, *handlers):
        """Replace current list of handlers with the provided ones."""
        for handler in self.logger.handlers:
            self.logger.removeHandler(handler)
            
        self.append_handlers(*handlers)
    
    def set_level(self, level):
        """Set logging level for current logger only."""
        self.logger.setLevel(level)
    
    def get_level(self):
        """Return current logger's level."""
        return self.logger.level
    
    level = property(get_level, set_level)
    
    def set_effective_level(self, level):
        """Set effective level: set the same logging level for current logger
        and for all its handlers.
        """
        for handler in self.logger.handlers:
            handler.setLevel(level)
        
        self.set_level(level)
        
    def get_effective_level(self):
        """Return effective logging level, any message produced with this level
        will be logged by all attached handlers.
        """
        return max([_.level for _ in self.logger.handlers] + [self.logger.level])

    effective_level = property(get_effective_level, set_effective_level)

    def set_options(self, *args, **kwargs):
        """Set logging options, see :class:`LoggingOptions` class above.
        
        :param args: here can be only one positional argument: instance of
            LoggingOptions object, if it's provided, keyword arguments will
            be ignored and current logging options will be replaced with
            provided ones;
        :param kwargs: any existing options can be provided (see available in
            :class:`LoggingOptions` class);
        """
        
        if args:
            if len(args) != 1:
                raise RuntimeError("Only one positional argument is allowed.")
            
            if not isinstance(args[0], LoggingOptions):
                raise TypeError("Provided argument must be an instance of"
                                " LoggingOptions object.")
            
            # The interesting moment to notice here. If we perform a direct
            # options assignment, we broke the link between self.options and
            # self.logger.options passed to _LoggerDecoratorHelper, when
            # decorator was created, thus any further call to log.set_options
            # method will not change already created decorators behavior.
            # So we need to set new properties one by one using 'setattr'.
            
            # self.options = args[0]
            kwargs = args[0].__dict__
        
        # process keyword arguments
        for key, value in kwargs.iteritems():
            if not hasattr(self.options, key):
                raise AttributeError("Unsupported option is passed: {0}"
                                     .format(key))
            
            setattr(self.options, key, value)
    
    def get_stream(self, level=None):
        """Return stream imitation object, which can replace stdout and
        stderr streams.
        
        :param level: logging level, messages will be logged with,
            usually :const:`STDOUT` or :const:`STDERR`;
        """
        return _LoggerStreamHelper(self, level or self.level)
        
    def __call__(self, msg, *args, **kwargs):
        """
        Calling log instance should act as usual 'info' or 'debug' methods.
        """
        return self.log(self.level, msg, *args, **kwargs)


# Default logger object, can be used right after it is imported. 
log = get_default_logger()


def info(msg, *args, **kwargs):
    """Log a message with severity ``INFO`` on the root logger."""
    return log.info(msg, *args, **kwargs)

    
def debug(msg, *args, **kwargs):
    """Log a message with severity ``DEBUG`` on the root logger."""
    return log.debug(msg, *args, **kwargs)
    
    
def warning(msg, *args, **kwargs):
    """Log a message with severity ``WARNING`` on the root logger."""
    return log.warning(msg, *args, **kwargs)
    
warn = warning 
 
def error(msg, *args, **kwargs):
    """Log a message with severity ``ERROR`` on the root logger."""
    return log.error(msg, *args, **kwargs)
    
    
def exception(msg, *args, **kwargs):
    """Log a message with severity ``ERROR`` on the root logger."""
    return log.exception(msg, *args, **kwargs)
    
    
def critical(msg, *args, **kwargs):
    """Log a message with severity ``CRITICAL`` on the root logger."""    
    return log.critical(msg, *args, **kwargs)

